0x00 写在前面
本实验是对 CVE-2018-1160 漏洞的调试分析和复现,所有实验过程均在本地搭建的虚拟机中进行。通过该实验比较深入地理解了该漏洞的成因和利用过程。
请严格遵守所在地法律法规。
0x01 程序分析
漏洞基本信息
Netatalk 是一个 Apple Filing Protocol(AFP) 的开源实现,为 Unix 系统提供了与 Macintosh 文件共享的功能。
CVE-2018-1160 是一个堆溢出漏洞,在 3.1.12 前的版本上在处理网络数据进行写入时未对写入数据长度进行判断,存在覆写漏洞,攻击者可以通过精巧的构建payload实现任意地址写。触发次数,可以多次发包,可以长时间反复发包。
根本原因就是在拷贝网络数据到结构体中时未对数据长度进行判断,造成溢出,导致攻击者可以进行任意内容的覆写,通过精巧的构建可以实现任意地址写,从而利用稳定的攻击路线实现getshell。
本实验是在 Ubuntu 18.04 x64
上开启 ASLR、NX、PIE、CANARY、FORTIFY;RELRO:FULL 等保护机制等情况下,实现远程 getshell 。
漏洞分析
漏洞点分析
在 netatalk-3.1.11/libatalk/dsi/dsi_opensess.c:34
处,注意到 dsi_opensession()
中的 memcpy(&dsi->attn_quantum, dsi->commands + i + 1, dsi->commands[i]);
并没有检查 dsi->commands[1]
的长度,而且 dsi->commands[2]
是我们发送的包里面的内容,也就是说 memcpy
中的数据内容、长度都是可控的,我们可以覆写 dsi
结构体里 dsi->attn_quantum
之后的内容。核心代码如下
因此这个地方存在一个溢出,用户可以对目标缓冲区 &dsi->attn_quantum
后的内容进行覆写。
任意地址写
在上述的越界写存在后,需要观测被越界写的地址后都有什么样的数据,所以需要观测目标地址的情况。
我们去观测一下 DSI
结构体,在 netatalk-3.1.11/include/atalk/dsi.h:60
中定义了 struct DSI
,如下图所示
从这个定义中可以看到溢出位置往后还有众多的变量和指针。需要注意的是,上述漏洞位置控制数据拷贝长度的值是 dsi->commands[1]
,因此这个长度最多只能是一个字节,即 0xff
,因此往后最多能写的位置并不多,而通过观测我们可以发现在 attn_quantum
后面有一个超大的缓冲区 uint8_t data[DSI_DATASIZ]
,长度有 65536
,因此,我们最多只能覆盖到这个缓冲区的部分。而这其中有一个有意思的域是 uint8_t *commands; /* DSI recieve buffer */
从注释中能知道,这个域是用于接收 DSI
数据的缓冲区。
简单跟踪该域的使用位置,可以发现在 /netatalk-3.1.11/libatalk/dsi/dsi_stream.c:634
处, commands
被传递进入了函数 dsi_stream_read
,而继续跟踪调用链 dsi_stream_read(dsi, dsi->commands, dsi->cmdlen)-> buf_read(dsi, (uint8_t *) data + stored, length - stored)-> from_buf(dsi, buf, count)-> memcpy(buf, dsi->start, nbe);
可以发现最终是将网络数据包中的内容写入到了 commands
所指向的地址中。
这里我将控制流中比较重要的部分剥离出来形成了一个单独的控制流代码如下
因此,任意地址写的方法就有了,通过第一次发包,利用漏洞点溢出覆盖 attn_quantum+16
位置的 commands
为希望写入数据的地址,然后再发送一次数据包包含希望写入的值,从而在第二次触发向 commands
所指向的内存位置写入任意的值。
爆破 libc 基地址
根据初始化 DSI
的过程, dsi->commands
是 malloc
分配的,但是 dsi->server_quantum
的初始值为 DSI_SERVQUANT_DEF = 0x100000L
,超过了 brk()
能分配的范围,所以是 mmap
分配的内存空间。
而当使用前面的任意地址写任意值时,如果尝试在一个不合法地址写入数据将会导致程序的崩溃,而程序一旦崩溃则无法进行响应返回。
在 Netatalk
工作的过程中,是由主进程来不断监听过来的请求,然后 fork
子进程对具体的请求进行处理,因此父进程和子进程享用共同的 mmap
基地址。
因此,通过不断尝试写入的地址,观测响应数据是否正常来爆破出一个合法的地址。需要注意的是,如果直接爆破有可能爆破出来的地址比 libc.so
的地址高,也有可能比 libc.so
的地址低,原因是只要能够进行写的操作那就是合法的地址,但 mmap
的地址在调试过程中是比 libc.so
的地址要高的,所以可以进行部分位的倒序爆破。爆破出来 mmap
地址就能爆破出 libc
的基址了,自然就可以改写地址进行控制流劫持了,通过不断往前做减法,一直枚举尝试一个可行的范围即可。
劫持控制流
在拥有任意地址写任意数据的能力后,并且成功得到了 libc
的基地址,所以后续的流程是相对固定的。这里采用劫持 __free_hook
的方法。通过 SROP
构建一个 gadget
链执行 system[cmd]
。在 libc2.77.so
中有一个很方便的 gadget:setcontext
,这个 gadget
会依次对寄存器进行赋值,值的内容和 rdi
有关。
因此,只要我们能够控制 rdi
寄存器,那么我们就能控制几乎所有的寄存器,包括 rsp
和 rip
,也就是说我们就达成了劫持控制流、控制了几乎所有寄存器。
这一段 gadgets
其实就是在进行 SROP
中 signal frame
的构建,此时 rdi
相对于指向就是 signal frame
的顶部。因此,我们可以通过 pwntools
中的 SigreturnFrame
方便的控制这段代码对寄存器的赋值,只要我们可以控制 rdi
。
但是这样就又要控制 rdi
并且要求 rdi
指向的内存可控。为了控制 rdi
,我们需要 __libc_dlopen_mode+0x35
处的一段 gadget
。这一段代码将_dl_open_hook
指向的内存赋给 rax
,然后调用rax指向的内存地址. _dl_open_hook
的位置位于 _free_hook + 0x2BC0
,且 *_dl_open_hook = _dl_open_hook + 8
。如图所示
然后 ROPgadget
找一下 mov rdi, rax
相关的内容,可以找到一个是 fgetpos64+0xcf
,如图所示
所以拼起来就是
1 | ; __libc_dlopen_mode+0x35 |
在 libc2.27
中,_dl_open_hook
地址比 free_hook
大约高 0x2b00
左右(不同版本编译器编译出来的 libc2.27
可能略有差别,但总体大约 0x2b00
左右),在本文中能够覆盖到,因此我们将 commands
指针覆盖至 free_hook
的地址处,随后根据三条 gadgets
的调用链,依次往后布局内存,使得我们最终能够控制 rdi
,进而控制程序流以及几乎所有寄存器,完成 RCE
。
注意,因为 rdi
距离 SigReturnFrame
有 0x28
字节的距离,所以 SigReturnFrame
要跳过前 0x28
字节。
这里本文的利用过程中与参考博客一致,比较粗糙,直接从
0x52085
处开始使用ROP
,这导致了第一条指令是mov rsp, [rdi+0xa0]
,于是乎,我们将这个ROP
放上去之后,程序执行过程中会将rsp
寄存器的值改掉,而我们后续需要使用下面的0x520ae
处的指令push rcx
和0x520cf
处的ret
来实现将rcx
寄存器压栈后又弹出到rip
以实现将rip
寄存器劫持到system
的目的。所以我们就需要再给我们覆盖到rsp
的值找到一个可以写入的地址,用来伪造成push rcx
时候的栈顶,所以这里才需要去设计一下,让rdi+0xa0
处的值要成为__free_hook
处,这是一个可以写入的地址罢了。事实上,如果我们细致一点,从
0x5208c
处开始我们的ROP
,则rsp
的值就不会被覆盖,于是就可以直接使用原来的栈进行system
到rip
的传递,毕竟原来的栈,天然就是一个可写的地址。
漏洞总体利用思路
可以通过任意地址写任意内容破坏重要数据结构,利用 fork
机制下 server
的响应内容对 libc
基地址进行爆破,最后通过劫持 free_hook
控制流实现 RCE
。
- 爆破可写地址。通过不断尝试,从高到低爆破出第一个可写入地址从而爆破出libc的基地址。通过第一次控制溢出长度覆写
DSI
结构体后面的commands
指针的值,然后通过第二次发送数据往commands
指向位置写入任意值。 - 劫持通过
free_hook
劫持程序控制流实现RCE
。通过覆盖到_dl_open_hook
让控制流走入gadget
中,然后控制rdi
,然后利用setcontext
控制其他寄存器从而实现劫持所有寄存器实现RCE
。
地址泄露
由前面的分析,通过第一次发包可以替换 commands
为希望写入数据的地址,第二次发包为向该地址中写入数据。而如果写入数据的地址无效或者不合法将会触发程序报错,否则将不会报错。因此通过这个特性可以判断第一次放入的地址是否是一个可写的地址。
而该程序的工作方式为启动一个 main
工作流负责进行端口监听,当有新请求来时, fork
一个子进程处理该请求,因此每次请求都是从主进程 fork
的子进程,都与主进程拥有相同的内存空间,共享 mmap
地址,所以,可以爆破出 mmap
的值。
而在当前的调试环境中可以发现,各模块的加载顺序是相对固定的,而 afpd
模块在地址是高于 libc
模块的。而这些基地址都是 0x7fxxxxxxx000
的形式,因此通过从高到低只需要爆破中间的字节即可,通过从高到底爆破可以爆破出最高的一个可写入地址,因此还是比较快的。
而拿到可写入地址后,通过地址对齐可以得到最高模块的地址,然后通过向前跳页,不断尝试总能尝试到libc的起始地址,一旦尝试成功则可以完成利用。
0x02 环境构建
这里使用参考链接中的现成环境即可。如果要搭建自己的环境,所有下载的文件为: netatalk-3.1.11.tar.bz2 libc-2.27.so libatalk.so.18.0.0 afpd。
使用下列命令启动容器后,可以启动该服务。
1 | sudo docker run -p 548:548 -it --privileged=true cve-2018-1160:v1 /sbin/init |
要调试这里面的程序,使用下面的命令
如果有 pwndbg 等插件记得修改配置文件
1 | sudo gdb -q -p `pgrep -n afp` --ex "set follow-fork-mode child" |
0x03 直接利用
通过 gdb
挂载目标程序,查看内容中的 libc 基地址,如下图所示:
所以可以得到libc的基地址是 0x7ffff67a1000
。
在客户机监听起来:
1 | nc -lnvp 6666 |
然后在 exp 中填入该基地址,再运行 exp 即可成功执行命令,实现getshell。这里是在 exp 中执行了一个 ls 的命令,可以看到成功在对方机器执行了命令了。
0x04 调试分析过程
修改 commands 为 free_hook
在漏洞点前下断点,发送exp,此时观测内存中的数据,可以发现此时一切正常,我们根据定义将对应的一些域标注出来,具体包括:
1 | uint32_t attn_quantum, datasize, server_quantum; |
下断点
1 | b dsi_opensess.c:34 |
然后在漏洞点后下断点,观测此时内存中的数据
1 | b dsi_opensess.c:35 |
可以看到,此时从 attn_quantum
开始的数据都被覆盖掉了,其中 commands
被修改了 __free_hook
的地址了。
因此,第一次发送数据可以完成修改 commands
为希望覆写数据的位置即 __free_hook
。
修改 free_hook 的内容
写入第二次 payload 后的内存
可以看到 __free_hook
(0x7f46c66468e8)处的值已经是 __libc_dlopen_mode_56
了。而紧随其后的 __free_hook+8
(0x7f46c66468f0) 处的值就是填充的执行命令和padding。
而同时_dl_open_hook
(0x7f46c6649588) 处的值是 _dl_open_hook+8
(0x7f46c6649590) ,然后加入的值是 fgetpos64+0xcf
(0x00007f46c62d79cf) ,然后是0x18的填充后,在(0x7f46c66495b0)处的值是 setcontext+0x35
,而紧随其后是 0x40 的填充,然后是在 (0x7f46c66495f0)处 写入了 __free_hook+8
,而后是 0x30 的填充,然后在 (0x7f46c6649630)写入了 __free_hook
并紧随其后在 (0x7f46c6649638)写入了 system
。之后,只要一触发 free
即可劫持控制流。
具体的控制流劫持流程如下所示,通过上面的布置后,一旦程序进入 free 控制流,然后就会调用 __free_hook
函数,而此时该函数已经被劫持到右侧的ROP上,通过第一段ROP的执行,程序会跳转到第二段ROP执行,并且此时会有 rdi=rax=_dl_open_hook+8
从而实现控制 rdi
寄存器,从而实现利用 SROP 进行控制流劫持。具体 SROP 的工作流程是,在 _dl_open_hook+8+0x20
处布置第三段ROP,第二段ROP执行完毕后会跳转到第三段ROP,而此时通过 rdi
的位置,布置相对偏移实现其他寄存器的操作,具体这里是通过布置 rdi+0xa0
以修改 rsp
的值进行栈迁移,布置 rdi+0xa8
以修改 rcx
的值,布置 rdi+0x68
以修改 rdi
的值。而在执行过程中 rcx
会被压栈一次,并在结束时 ret
以修改 rip
的值,劫持控制流。
在 (0x00007f46c63bf318)处下断点后 continue 跟踪程序释放流程,可以看到目前处于第一段 ROP 中。
然后走两步,观察到进入了第二段ROP中,并且可以看到 rax
的值也是预期之中的。
于是再走两步,可以看到程序进入了第三段ROP中
此时,慢慢跟该段代码的执行,观测重要寄存器的修改 rsp
rcx
rdi
。
再单步执行一步,可以看到 rsp
已经被成功修改为 __free_hook
(0x7f46c66468e8)
然后再执行很多步,我们去观测 rcx
的值,可以看到此时 rcx
的值已经被写成了 system
,并且下一条指令将会把 rcx
的值压栈,因此 system
将会被压入栈顶中。
再执行一步,可以看到 system
此时被压入栈中,并处于栈顶。
然后一直执行到 rdi
被修改,可以看到此时 rdi
指向了我们将要想执行的命令的字符串。并且下一条要执行的指令为 ret
,而此时栈顶的元素依然是 system
,让 ret
执行后,此处栈顶的 system
将会被弹出到 rip
中,从而完成劫持控制流。pwndbg已经帮我们预判了后续的执行流程。
成功劫持控制流示意,此时 rip
就是 system
而对应的参数 rdi
指向我们想执行的命令的字符串。
0x05 总结
这个漏洞的利用过程还是比较巧妙的,灵活使用 pwntools
确实可以起到事半功倍的效果, SROP
的方法比自己人工找去配置确实方便多了。
要完成利用还是需要比较熟悉程序,对漏洞的能力需要有比较清晰的认识。基本思路还是要确定漏洞点、然后拿到任意地址写之后判断了写入能力为写任意内容就容易了很多,然后在利用 fork
机制爆破泄露 libc
的基地址最后结合ROP链实现了控制流劫持。